Repository navigation
feat: add the connected-app consent screen with step-up, and run the OAuth server in Production - #1144
Conversation
…th server in Production Replaces #795's self-approving stub. A browser navigation to the authorize endpoint carries no session bearer, so it is handed to the SPA's /connect route with its query. The SPA asks the same endpoint with its bearer and gets either what the consent screen shows or the client's redirect as JSON, because fetch cannot read a cross-origin redirect. Approval spends a step-up grant through the new IAccessModule.ConsumeStepUpGrantAsync and records a permanent authorization. A later request that asks for nothing new is approved silently; one that asks for more is shown again. The two scopes are farm:read and daily-entries:write. A Production serving process now runs the authorization server and refuses to start without an absolute https OAuth:Issuer. Discovery names its endpoints under the issuer rather than the request Host. The sim harness and deploy/.env.example carry the key. Sign-in returns only to a same-origin path; /connect keeps its query. Refs #798
The fixed window is aligned to the clock, so two requests that straddle a 60-second boundary both pass. AuthorizeEndpoint_IsRateLimited failed that way in CI and TokenEndpoint_IsRateLimited locally. A one-day window, as #840 did for MultiInstanceRateLimitTests, makes the crossing negligible. Refs #798
… included Maintainer decision on review round 1: a session bearer alone must never mint a code, because the code becomes a token that never expires. A reconnect still skips the permission explanation; the consent payload now says alreadyApproved so the SPA can ask for the password only. Also from round 1: - The redirect host comes from the redirect URI OpenIddict validated, so a single-URI client that omits redirect_uri gets a consent payload rather than a 500. - The expired-session test uses a signed session JWT expired past the clock skew; the malformed-token case keeps its own test. - The sim verifier refuses an issuer with a query or fragment, as the boot guard does. Refs #798
|
Review round 1, from Codex GPT-6 Astra at head 2624649. Every finding is addressed at dd48441. P1, the silent skip on a reconnect. The maintainer decided that a reconnect skips the permissions but always asks for the password. Since b0fceb9, no path issues a code without a step-up grant spent through
P2, an omitted P3, the expired-session test. Fixed in b0fceb9. P3, the sim verifier. Fixed in b0fceb9. Verification at dd48441. The |
|
Review round 2 by Codex GPT-6 Astra, run locally on head dd48441, found 0 P1, 0 P2 and 2 P3. Every route that produces a code now spends a grant bound to this user and farm, and the redirect crash is fixed. The two P3s are small, and they will be fixed alongside the UI commits:
The backend review loop for this PR is complete. The UI waits on the maintainer's consent pick and gets its own review. |
The login screen continues to consent and names the app it continues to. An anonymous authorize request carrying X-Cluckwork-Consent: preview gets the validated client's self-chosen name and nothing else. Also from review round 2: the Authorize comment no longer promises a silent reconnect, and the sim verifier refuses a bare "?" or "#" in OAuth__Issuer, as the boot guard does. Refs #798
… step-up field Refs #798
…ing, and the OAuth screen picks The write permission reads Record daily entries with no promise about drafts; #809 adds that once the write tool's behaviour is settled. The maintainer approved a second milestone precache raise to 1,950 KiB. docs/designs/788-oauth-screens/ carries the direction lab and the picks. Refs #798
The password field is required, so an empty submit is refused by the form. Refs #798
|
Screenshots for #798, captured at 1:1 from the isolated Login, before. An ordinary sign-in. This code path does not change in this PR, and before this PR a sign-in that continued to a connected app looked the same, because Login B, after. Sign-in that continues to a connected app names the app. The box sits in the brand panel at 1280 and above the form at 390. Consent D. The account email sits at the top in normal text colour and semibold weight. Wrong password. Asks for more. The app was allowed to read farm data, and now also asks to record daily entries. Details open. For this flock-scoped Worker, it names the one assigned flock the app could reach. Reconnect. The app was already approved for everything it asks, so the screen asks for the password only. #778 proof, on the built SPA with the service worker in control. The same run as the screenshots, recorded by the capture script:
|
CodeQL read the lab's long-strings length estimate as an incomplete sanitizer. It counts the characters outside markup instead; all 207 frames render identically and without page errors. Refs #798
Resolutions: - web/scripts/verify-sw.mjs and docs/designs/822-mui-revamp.md: the ceiling is 1,950 KiB, with #800's and #798's dated notes both kept. - OAuthEndpoints.Authorize keeps #798's consent with step-up and adds #800's client_name claim, read from the application the handler already loaded. - AuditConnectedAppTests connects through the step-up approval, since the self-approving stub is gone. Refs #798
…e request Review round 3 (Codex GPT-6 Astra): - Details built its access sentence from the role name alone. The consent payload now carries assignedFlocks, the flock scope this request resolved (null for every flock, else the assigned flocks' names, through IAccessModule), and Details states what the requested scopes would get within that scope and the role. - Focus starts at the headline instead of the password, for consent and reconnect; a wrong password returns focus to the field. - The account email drops body1's tabular figures, which spaced its hyphens. Refs #798
|
Review round 3, from Codex GPT-6 Astra at head 6368134, and the merge with Merge with #800. f044a04 merges
P2, Details described access from the role alone. Fixed in d4cc60d.
P2, password autofocus skipped the request. Fixed in d4cc60d.
P3, the email's hyphens. Fixed in d4cc60d. The email sets Verification.
|
|
UI round 2 by Codex GPT-6 Astra, run locally on head 887a7b2, found 0 P1, 0 P2 and 0 P3. It confirmed:
The review loop for this PR is complete. Release note: this PR ships in the same release as #799, and the deployment repo must set |














Closes #798
Slice 4 of #788. This PR replaces #795's self-approving stub with real consent, adds the consent screen a connected app sends people to, and turns the OAuth server on in Production.
Before a release with this change is deployed
The deployment repo must set
OAuth__Issuerto the public https URL, for examplehttps://farm.example/. Without it the Production serving container refuses to start. One-shot verbs (migrate,bootstrap-adminand the others) do not need it.Release note
This PR must ship in the same release as #799 (Connected apps on Account). The consent screen tells people to "Disconnect it anytime in Account › Connected apps", and #799 builds that place. The maintainer's rule is that no release is cut between merging #798 and #799.
What people see
Screenshots at 1280 and 390, from a stack rebuilt at the head, are in the screenshot comment on this PR. The picks are in
docs/designs/788-oauth-screens/SELECTION.md, and the direction lab is beside it./connect. A compact card: the question, an Unverified app marker, two permission rows, where the browser returns, and "Acts as you, with only your access" with the user's role. Then Your current password, Allow and Cancel, the undo line, and a closed Details section. The signed-in email sits at the top in normal text colour and semibold weight, and Not you? Sign out is in Details. Details states what the request would reach: its scopes, within the user's role and flock scope. A Worker with assignments reads the assigned flocks' names; a Worker with none, like every other role, reads every flock. Focus starts at the headline, so a screen reader begins at the request and a phone keeps its keyboard closed; a wrong password returns focus to the field. "Record daily entries" makes no promise about drafts; MCP slice 6: write tool — record daily entry #809 adds that line once the write tool's behaviour is settled. A role that cannot record entries sees "Not allowed for your role" on that row.StepUpPasswordFieldreplaces the six inline copies on the Users screen and serves the consent card. The 167 Users specs pass unchanged.GLOSSARY.md("Connecting an app", next to OAuth slice 6: audit provenance — record which application acted, alongside the human #800's "Connected app").Precache
The maintainer approved a second milestone raise, to 1,950 KiB.
web/scripts/verify-sw.mjsanddocs/designs/822-mui-revamp.mdrecord it beside #800's earlier note. After mergingmainwith #800, this branch measures 1,923.35 KiB, so #798 adds about 20 KiB on top of #800. About 10 KiB of that is strings in three languages, and the rest is code. The planned way to win the space back is to stop precaching the languages a device does not use, which is tracked separately.How the flow works
The SPA holds its access token in tab memory. A browser navigation to the authorize endpoint therefore carries no bearer, and
fetchcannot read a cross-origin redirect. SoGET /api/v1/oauth/authorizeanswers three callers./connectwith the same query, which OpenIddict has already validated{ redirectUri }for the SPA to navigate toAllow repeats the request with
X-Cluckwork-Step-Up. Cancel repeats it withX-Cluckwork-Consent: deny, and the client then receivesaccess_denied. The consent payload is{ clientId, clientName, redirectHost, scopes, alreadyAllowed, alreadyApproved, assignedFlocks }.assignedFlocksis the flock scope this request resolved: null for every flock, else the assigned flocks' names, read throughIAccessModule. WhenalreadyApprovedis true, the SPA asks for the password only.Decisions
IAccessModulemethod.ConsumeStepUpGrantAsync(accountId, userId, stepUpToken, ct)validates and spends a Require recent step-up authentication for privileged user administration #308 grant, the same way the user-administration handlers do.IStepUpGrantServicestays internal to Access. The ledger gains the adapter rowOAuthEndpoints.Authorize -> Access.alreadyApproved, and the reconnect asks for the password only and reuses that authorization. A wider request shows the permissions again, andalreadyAllowednames what was approved before.redirect_uri.farm:readanddaily-entries:write. A request with no scope defaults tofarm:read. Scope permissions are ignored per client, because every client registers itself anonymously.OAuth:Issuer. It must be an absolute https URL with no query or fragment, and the check is serving-only (refactor(api): make process role explicit for boot guards #347). The sim harness,deploy/.env.example,ProcessRoleGuardTests(two rows) andServingGuardCoverageTestsall carry it. The AppHost runs Development, whereappsettings.Development.jsonalready sets an issuer. Its tests pass unchanged.returnPathsends anything that resolves to another origin back home. That covers absolute URLs,//hostand/\host. Only/connectkeeps its query.Decision: a reconnect skips the permissions, never the password
The first version skipped both the screen and the password when an app asked for nothing new. Review showed what that allowed. Someone with only a stolen session access token could get a code for an already-approved app from the JSON response. That code becomes a token that never expires. The maintainer decided that every connection asks for the password, and a reconnect skips only the permission explanation. App tokens never expire, so people reconnect rarely, and the password costs little each time.
docs/decisions/798-oauth-consent.mdrecords the decision and the alternative it rejected, which was validating the refresh cookie as a second browser proof.Proof
OAuthConsentTests(new) drives each consent rule. A bearer-only reconnect gets no code. A reconnect spends its grant, and a replayed grant is refused. The suite also covers the JSON redirect withstateandiss, cancel, a signed session JWT expired past the 30-second skew, a malformed bearer, an omittedredirect_uri, the read-only default, refused response modes, an unknown scope, and discovery from a second host.OAuthConsentTests.Preview_WithoutASession_NamesTheAppOnlypins the login preview to the app's name alone.OAuthServerProductionTestsruns the whole connect flow in a Production host: registration, the navigation hand-off, consent, step-up and token redemption. It also shows that four bad issuers stop the host from starting, a bare#among them.OAuthfilter (102/102) andCredentialEpochTests;ProcessRoleGuardTests(58/58),ServingGuardCoverageTests,FixturePortRegistrationTestsandOtlpSubprocessExporterTests(11/11) earlier in this PR; the Application architecture tests; the AppHost tests;tsc; and the Vitest specs below.ConnectPagespecs. They cover each screen state (normal, wrong password, asks for more, reconnect, Details and sign-out, a role that cannot record, a web app's host, cancel, refused, the prominent email). They also cover Details for a Worker with assignments, a Worker with none, a Manager asking only to read, and a Manager asking for both, plus initial focus for consent and reconnect and focus after a wrong password. Also 3 Login B specs, 1 AuthShell spec, the 167 Users specs on the shared field, and the style, i18n, Help and glossary guards.assignedFlocksfails the scoped-Worker spec, and choosing the write sentence by role alone fails the read-only spec. Removing the heading focus fails both initial-focus specs, and removing the retry focus fails the wrong-password spec. PuttingautoFocusback on the password survives, as an equivalent mutant: the heading-focus effect runs after the input mounts and takes focus back, so focus still starts at the request.tools/oauth/mutation-check.shhas 86 mutants. The full run at 6368134 killed 83 and heldredirect-fragment, where OpenIddict still refuses the URI. After the merge with OAuth slice 6: audit provenance — record which application acted, alongside the human #800 and review round 3, I reran all 27 mutants onOAuthEndpoints.cs, which both changed, at 887a7b2. They include the two new ones:scoped-worker-unrestricted(a scoped Worker told every flock) andunassigned-worker-scoped(an unassigned Worker told a flock list). All 27 are killed, with baseline and restore green at 105/105. One of them,reconnect-without-password, needed its anchor regenerated for the payload's new field first. The 59 mutants on other files last ran at 6368134; the merge changed none of the files they mutate./connectquery fails the Login test.tools/simulation/verify-harness.shpasses the bootstrapped.env.simand refusesOAuth__Issuer=https://cluckwork-sim.local/?tenant=1, and it now refuses a bare?or#as the boot guard does.AuthorizeEndpoint_IsRateLimitedfailed that way in CI, andTokenEndpoint_IsRateLimitedfailed that way locally. Both tests came from OAuth slice 2: fail-closed checks on the OAuth path (disabled, suspended, must-change-password, flock scope, rate limit) #796. Commit 2624649 pins a one-day window in the tests that tighten an OAuth limit, as CI: root-cause the four remaining integration flakes #840 did forMultiInstanceRateLimitTests.cw798stack ran Production config, rebuilt at the head, behind an https edge with a matchingOAuth__Issuer. A browser navigation to the authorize endpoint went to/connectand then to/login, which named the app, with the service worker in control. After sign-in, the browser returned to/connectwith the request intact. Allow with the password reached the app's loopback callback withcodeandstate, and a password-only reconnect issued a code that redeemed with 200. One earlier run of the capture script timed out waiting for the callback on the last reconnect. The app logged no error for it, and three reruns passed, so it is recorded here rather than explained.For #799 and later
promptandmax_ageare ignored, soprompt=consentcannot force the screen.